Skip to content

Add gui.add_panel() for floating side-by-side control panels#711

Open
ArthurAllshire wants to merge 89 commits into
viser-project:mainfrom
ArthurAllshire:add-panel-api
Open

Add gui.add_panel() for floating side-by-side control panels#711
ArthurAllshire wants to merge 89 commits into
viser-project:mainfrom
ArthurAllshire:add-panel-api

Conversation

@ArthurAllshire

@ArthurAllshire ArthurAllshire commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Screen.Recording.2026-05-04.at.8.10.40.PM_compressed.mp4

Adds viser.GuiApi.add_panel(), which opens a draggable, resizable floating window as a sibling of the main control panel. Useful when the default single-panel layout gets too tall or too narrow — panels can be placed side-by-side and sized independently.

with server.gui.add_panel(
    \"Cameras\",
    initial_position=(\"center\", 20),
    initial_width_px=540,
    layout=\"row\",
) as panel:
    for name in (\"left\", \"top\", \"right\"):
        server.gui.add_image(frame, label=f\"Camera {name}\")

GuiPanelHandle subclasses GuiFolderHandle, so it works as a context manager exactly like folders do — the only difference is that the panel renders as its own floating window instead of inline in the main panel.

Features

  • Drag to move: grab the title bar.
  • Drag to resize: grab the right edge (6px strip, tinted blue on hover).
  • Persistence: per-panel position + width saved to localStorage (keyed by the panel's UUID).
  • Positioning: initial_position=(x, y) accepts int | \"center\" for either axis. Negative integers anchor to the right/bottom edge (e.g. (-20, 20) is 20px from the top-right corner).
  • layout=\"row\": renders children side-by-side with equal flex share, non-wrapping — useful for rows of camera/video feeds.
  • Flicker gate: a resizingRef suppresses the ResizeObserver's reposition pass during an active drag so width updates don't fight it.

Backward-compatible: all changes are additive. SidebarPanel, BottomPanel, and existing FloatingPanel call sites are untouched.

Demo

examples/02_gui/11_panels.py — standalone demo with animated "video" feeds. Shows the main panel alongside three user panels (top-left, centered, bottom-right), with working Zoom slider and Reset button. Run with python examples/02_gui/11_panels.py and open http://localhost:8080.

Stats

13 files changed, 528 insertions(+), 6 deletions(-). Largest changes are in UserPanels.tsx (new, 121 lines — the renderer) and FloatingPanel.tsx (+101, adds the resize handle and initialPosition / onGeometryChange props).

Test plan

  • examples/02_gui/11_panels.py runs; all three panels visible and animate.
  • Drag a panel's header — it moves, doesn't overlap neighbors if you pick them apart.
  • Drag the right edge — panel resizes; no flicker.
  • Reload the page — panel positions and widths persist.
  • Resize the browser window — \"center\" x keeps the Cameras panel horizontally centered; negative-y keeps Status anchored to the bottom.
  • Row layout: at very narrow widths, images shrink together instead of wrapping or overflowing.
  • Existing add_folder, configure_theme, sidebar layout, and bottom (mobile) layout are unchanged — verified via 02_gui/00_basic_controls.py and 02_gui/05_theming.py.

Open questions / happy to bikeshed

API shape wasn't obvious — would love maintainer input on any of these before you merge:

  1. initial_position convention. I used int | \"center\" with negative = right/bottom anchor. Clean in the common case, but the negative-sign thing is a little clever. An alternative would be an explicit anchor: Literal[\"top-left\", \"top-right\", \"top-center\", \"bottom-left\", \"bottom-right\"] = \"top-left\" field with all-positive offsets. Same expressiveness, more verbose.
  2. layout=\"row\". Currently only exists on panels. If this feels generally useful, it could move to add_folder / a new add_row container. Kept it panel-only here to minimize surface area.
  3. Persistence. Lives entirely in UserPanels.tsx and persists by panel UUID. If uuids aren't stable across Python restarts (they aren't — uuid.uuid4()), persistence only survives browser reloads, not server restarts. Happy to key on title instead if that's preferable, or make persistence opt-in via a prop.
  4. Panel-placement collision handling. None — panels are positioned independently; on narrow viewports they can overlap (the demo works around this by staggering y-offsets). Could add a simple stacking fallback in UserPanels.tsx, or leave it to the user.

Also: I ran ruff (passes), npm run typecheck (passes), and smoke-tested existing GUI examples (00_basic_controls.py, 01_callbacks.py, 05_theming.py). I couldn't run pyright locally due to an unrelated environment issue — CI should catch any type problems.


🤖 Generated with Claude Code

Adds viser.GuiApi.add_panel(), which opens a draggable, resizable
floating window as a sibling of the main control panel. Useful when
the default single-panel layout gets too tall or too narrow — panels
can be placed side-by-side and sized independently.

Features:
- Drag title bar to move, right edge to resize.
- Position and width persist to localStorage per-panel uuid.
- initial_position=(x, y); negative values anchor to right/bottom edge.
- layout="row" renders children side-by-side with equal flex share.

Example: examples/02_gui/11_panels.py.
test_docs_coverage.py was failing because GuiPanelHandle wasn't listed
in docs/source/api/handles/gui_handles.rst. Fixed by re-running
`python docs/generate_handle_docs.py`.
abcamiletto and others added 24 commits June 22, 2026 10:50
* Fix skinned mesh playback loop refs

* Add regression test for skinned mesh playback ref preservation

Restores the unit test covering nodeRefFromName handling in addSceneNode:
refs are preserved when the same creation message object is replayed
(playback loops, issue viser-project#728), cleared on a different message (live
updates), and untouched for brand-new nodes.

Re-exports createSceneTreeActions so the test can drive the action logic
directly; it was demoted to module-private during the .tsx -> .ts refactor.

---------

Co-authored-by: brentyi <yibrenth@gmail.com>
* Fix type error from stub change to `np.cross()`

* Add missing `scales` cast
Refactor standalone panels to a dedicated GuiPanelMessage entity (not a
GuiTabGroupMessage under "root"), eliminating the isStandalonePanel inline-render
filters. Plus a batch of placement/sizing fixes and structural hardening of the
dock layout module.

API / behavior:
- add_panel is its own top-level entity (like add_modal); add_tab via a shared
  _TabContainerMixin. expand_by_default replaces minimize()/expand().
- float() is viewport/canvas-relative; negative coords are gaps from the far edge
  (x=-15 = 15px from the right), re-resolved as the canvas changes.
- visible=False hides a panel (panes removed) without destroying it.
- main_panel.float() un-docks; configure_theme(control_layout=...) deprecation.
- Disconnect freezes + dims the GUI instead of wiping it; reconnect replays.

Fixes:
- Float canvas-relative + push/clamp within canvas on dock/resize changes.
- Auto-height windows: content-aware floor + revert-to-auto (no pin-trap);
  cappedWindowHeight never inflates a small window past its pinned height.
- Resize handle sits between a minimized strip and the expanded panel.
- Re-placing a panel re-gathers tabs dragged out into other groups (no dupes).
- Narrow "Connected" header truncates cleanly; min region width 96.

Correct-by-construction (dock layout):
- Extract invariantViolations into layoutInvariants.ts; applyOp asserts it on
  every commit in dev; the fuzz suite imports the same checker.
- movePaneInPlace primitive (detach-then-insert) so a pane can't live in two
  groups; ensurePanelGroup's gather routes through it.
- Remaining union refactors tracked in design/dock-correct-by-construction.md.
Tests built FloatingWindow objects as raw {id,x,y,width,stack} literals in ~100
places (and makeLayout/floatingLayout re-declared the shape inline), coupling the
model to test literals -- which is what made every window-model change expensive.

Add floatingWindow() to testUtils as the ONE place a FloatingWindow is
constructed; route makeLayout and floatingLayout through it; migrate every raw
literal to the factory. Behavior-preserving (403 vitest, 39 e2e unchanged). A
future field change (e.g. a tagged-union for height/position) now touches the
factory, not the literals.
Replace FloatingWindow.height?: number -- which overloaded "auto-size"
(undefined) and "pinned px" (number) -- with a tagged union
{ mode: "auto" } | { mode: "pinned"; px: number }.

Makes the height pin-trap and the sentinel-undefined ambiguity unrepresentable:
"auto vs pinned" is an explicit total state, and reverting to auto is a real
named transition instead of `delete win.height`. Production reads branch on
`.mode`; the testUtils floatingWindow() factory translates the terse numeric test
opt, so call sites stay terse. The dev invariant assert validates px finiteness.

Verified: 403 vitest (incl. fuzz), 29 e2e, tsc/eslint clean; pin-trap fix
re-confirmed end-to-end (button panel resizes and reverts to content height).
Replace the FloatingWindow.requestedX?/requestedY? PAIR with one optional
`anchor: { x; y }`. Presence is the ownership tag: an anchored window re-resolves
its canvas-relative anchor (negative = gap-from-far-edge) as the canvas/size
change; absence means user-owned at its absolute x/y.

Collapsing the two coords into one object makes "half-set ownership"
unrepresentable -- the hazard where a gesture set one coord but not the other.
markWindowUserOwned is now a single `delete win.anchor`. Stored absolute x/y are
kept (hit-test/drag/render read them unchanged), so this is the lighter form of
the placement union: it captures the correctness win without rewriting the
delicate grab-offset/grip-resize code (see design/dock-correct-by-construction.md).

The Python window() helper (dock_helpers.py) is the one e2e seam that builds
floating-window layouts; updated it for the WindowHeight union too.

Verified: 403 vitest, 297 e2e (0 failed), tsc/eslint/ruff clean, 28 Python.
… aliasing

Two issues found auditing the just-landed union refactors:

- The dev invariant assert (and the shared fuzz checker) flagged any empty-paneIds
  group, but an area-backing group is LEGITIMATELY empty -- it persists as a "drop
  a panel here" affordance after its last tab is removed or torn out. This fired
  console.error on real dev flows (an empty inline tab group, or removing the last
  area tab). Rule 5 now exempts area groups from the empty check (a non-empty area
  group is still validated). The fuzz suite never exercises areas, so it missed
  this. Added regression tests (empty area via ensureArea + add/remove, plus a
  sanity check that a non-area empty group is still flagged).

- snapToWindowStack adopted the source window's height object by reference from
  the ORIGINAL layout, sharing it with the committed draft. Harmless today (height
  is always replaced wholesale, never mutated in place) but a footgun the union
  introduced; copy it instead.

Also added an op-level regression test for re-gathering panes the user scattered
into separate windows (movePaneInPlace gather).

Verified: 405 vitest (incl. fuzz), 37 e2e, tsc/eslint clean.
…anvas

Docking enough panels that the left + right regions' summed reserved width
exceeds the viewport (e.g. 3 left + 3 right at the 300px default on a 1280px
screen = 1800px) made the two regions overlap and fully cover the 3D canvas --
hiding the scene and trapping the panels' own controls underneath.

Add a render-time guard in DockManager: when the regions' summed reserved width
would leave less than MIN_CANVAS_PX of scene, scale BOTH regions down
proportionally so a usable canvas strip always remains. This is render-only --
the MODEL region widths (regionWidth) are untouched, so widths restore when the
viewport grows back. The capped insets flow consistently to hit-testing, the
rendered region divs, and float clamping (all read the same reservedWidth).

Found by an adversarial visual hunt; added an e2e regression
(test_many_docked_panels_do_not_occlude_canvas) asserting the viewport center
resolves to the CANVAS, not a docked region.

Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean.
From an audit pass focused on remaining "valid-only-by-convention" spots and
avoidable complexity. All behavior-preserving.

- WindowHeight: add windowHeight(px?) / pinnedPxOf(h) helpers and route the 7
  open-coded construct/access sites (across types/layoutOps/DockManager/
  FloatingWindowView/testUtils) through them. The union's "no sentinel ambiguity"
  win was being undercut by `.mode === "pinned" ? .px : <fallback>` scattered
  with three different fallbacks; now there's one constructor and one accessor.

- Python tab tuples: _tab_labels / _tab_icons_html / _tab_container_ids were kept
  in lockstep by hand (separate appends, three splices on remove, .index(self)
  arithmetic in the icon setter -- the most fragile spot). Make _tab_handles the
  single source and rederive all three via one _rebuild_tab_props(). Desync in
  length/order is now unrepresentable.

- DockManager: extract a single commit(next) that does the dev invariant check +
  state update, and route BOTH applyOp and restoreLayout through it -- so "every
  committed layout is structurally checked in dev" is literally true, not "true
  except restoreLayout."

- Rename releaseRequestedCoords -> releaseAnchor and the resolve params
  requestedX/Y -> anchorX/Y, matching the `anchor` field vocabulary (the old
  requested* names were leftover drift).

Verified: 405 vitest, 298 e2e (0 failed), tsc/eslint/ruff clean, msgs in sync,
28 panel tests; tab-tuple sync re-confirmed (add/remove-middle/icon-change).
Re-placing a panel that's already floating solo (e.g. a later set_height /
set_width, which re-runs applyPanelPlacement with the coalesced placement) went
through floatGroup unconditionally: detach + mint a NEW window every time. That
churned the window id, reset its z-order to front, and re-resolved position from
the stored server anchor -- so set_height on a panel the USER had dragged yanked
it back to the anchor position.

floatAtRequested now reuses the existing window when the group is its sole
occupant: it updates width/height in place, keeps the window id + z-order, and
PRESERVES the user's position when the user owns it (anchor cleared by a drag) --
only (re)anchoring + resolving for a fresh float or a still-anchored window. A
docked->float or multi-group-stack re-placement still makes a fresh window.

Found while polish-hunting the floating-panel height behavior. Added op-level
regressions: set_height keeps the same window id + pins the height; a size-only
re-placement does not move a user-dragged panel. Verified end-to-end (drag to
(380,413) then set_height(420): height applies, position preserved).

Verified: 407 vitest (incl. fuzz), 298 e2e (0 failed), tsc/eslint clean.
A size-only re-placement on a panel already docked on the target edge (e.g.
`panel.dock_right(); panel.set_width(520)`, two statements -- exactly the
add_panel docstring example) silently did nothing: the region stayed at its
default width.

Cause: applyPanelPlacement re-ran `dockToEdge` on the already-docked group,
which detaches + recreates the leaf with a NEW node id. The width reconciler
then sees a changed column set, treats it as a new column, and resets the width
to the carried-over/default value -- discarding the requested width. (The
position-changing case worked only because the width rode that placement.)

Fix: skip the re-dock when the group is already docked on the target edge, so
the leaf's node id stays stable and the size branch + reconciler's same-columns
path apply the new width. Mirrors the floating window-reuse fix from the prior
commit. Added an op-level regression (set_width changes the docked region width;
node id unchanged).

Verified: 408 vitest (incl. fuzz + width reconciliation), 298 e2e (0 failed),
docked + floating set_width confirmed end-to-end (300->520->150 / 240->420->300).
A tab label (or single-tab panel header) longer than the tab's max width was cut
off mid-character with no "...". The tab/header Box is `display:flex` with the
title as a raw text child, and `text-overflow:ellipsis` is ignored on a flex
container -- so it clipped with no ellipsis.

Move the overflow/text-overflow/white-space onto an inner <span> (a non-flex
block) and give it minWidth:0 so it can shrink within the flex row; the icon box
gets flexShrink:0 so only the label truncates. Applies to both the tab strip and
the single-tab header's plain-title branch.

Verified end-to-end: a long label now renders "ThisIsAnExtremelyLon..." with a
proper ellipsis. tsc/eslint clean, 408 vitest, 298 e2e.
… offset)

Three related minimized-panel fixes:

- Minimized DOCKED multi-tab strip: was "{active icon} Alpha / Beta / Gamma"
  (one arbitrary icon + all labels joined, rotated) -- confusing and per-tab
  clicks were impossible. Now render ONE ROW PER TAB (upright icon + rotated
  label, active row highlighted). Each row is its own click target.

- Clicking a tab expands the panel: added DockContext.expandToTab (setActiveTab
  + expandGroup, composed in one op). The minimized strip rows AND the no-motion
  tab-click in startTabDrag both route through it, so clicking a tab to read it
  reveals its content instead of switching a hidden active tab. Keyboard tab
  nav still uses plain activateTab (arrow keys shouldn't expand).

- Tear-out grab offset: dragging a minimized panel out by grabbing the BOTTOM of
  its region-tall strip left a large cursor-to-ghost gap (grab was computed
  against the tall source, but the floated window is short). Clamp the grab
  offset into the floated window's actual rendered box so the cursor stays on it.

Verified end-to-end: 3-tab strip renders 3 clickable rows; clicking Beta expands
to Beta; bottom-grab tear-out keeps the cursor on the window. 408 vitest, e2e
regression added (test_minimized_multitab_strip_rows_expand_to_tab).
The press-to-window grab offset was open-coded in four drag starters, and only
the group-undock site clamped the offset into the floated window's box. The
column-undock site had the same latent gap bug (a region-tall column floats into
a height-capped window, so a low grab left a cursor-to-ghost gap) but no clamp.

Extract grabOffset(e, originX, originY, winId?): clamps into the floated window's
rendered box when winId is given (tear-outs whose source is bigger than the
result), else returns the raw offset (when the source IS the window). All four
sites route through it; the column-undock site now gets the same clamp.

Verified: 408 vitest, 299 e2e (0 failed); strip bottom-grab still lands on the
window through the refactored path.
The playground tests' vite dev server recursively watched the whole client dir,
including the huge .nodeenv and node_modules trees. Across many test runs this
exhausts the OS inotify watcher limit and crashes the server on startup (ENOSPC),
making the playground tests error at setup. The tests never edit source mid-run,
so ignore those trees in server.watch -- lighter servers, no watcher exhaustion.
…eError

PanelHandle.__enter__ raises a clear TypeError to catch the `with add_panel():`
mistake, but __exit__ had been removed on false reasoning. CPython looks up BOTH
dunders on the type BEFORE calling __enter__, so omitting __exit__ made
`with panel:` fail with a bare AttributeError and the helpful message never ran.
Restore __exit__; the regression test now uses a real `with panel:` (it had
masked the bug by calling __enter__() directly).
The per-tab rows in a minimized docked strip carried role=tab/aria-selected but
had no tabIndex, onKeyDown, or focus ring, and weren't wrapped in a role=tablist
-- claiming to be tabs while being mouse-only (a role-without-keyboard-support
a11y break, and role=tab outside a tablist). Wrap the rows in a
role=tablist (aria-orientation vertical), make each row tabIndex=0 + focusRing +
onKeyDown (Up/Down move focus, Enter/Space expand-to-that-tab via expandToTab),
mirroring the expanded tab strip. Verified: rows focusable, Enter expands.
dock -> minimize -> drag out -> expand produced a ~96px-wide window: floatRectFor
measured the collapsed strip's narrow DOM width and used it as the floated
window's width, persisting after expand. When tearing out a MINIMIZED docked
group, float it at the region's preserved expanded width (regionWidth survives
minimization for restore) instead of the measured strip width. Added an e2e
regression (dock->minimize->undock->expand stays wide).
Dropping a dragged panel as the leftmost tab (insert index 0) of a docked panel
failed in some configs: a region-edge band spans the whole region, so a tab strip
flush at the region's top/left edge sits inside the band, and section-2 region
edges (checked before per-panel zones) won -- docking a region span instead of
inserting at the tab. This hit EVERY panel in a stacked column (leftmost tab at
x=0, inside the 40px left band) and topmost panels in a multi-column row (strip
inside the 8px top band).

Skip the region-edge bands when the pointer is over an INSERTABLE tab strip (a
mergeable docked/floating group where tabInsertion resolves, drag itself
mergeable). A strip drop is a more specific intent than a region span. Outermost
region-edge docking is unaffected -- content area, grip bar, and screen edges
aren't strips. Added hitTest regression tests (column + topmost-row configs, plus
draggingUnmergeable still gets the region span). Verified live: drop on a stacked
panel's leftmost tab now inserts at index 0.
A minimized vertical strip is wayfinding chrome (a label/affordance), not
content, so emphasizing the active tab with the primary color + bold there just
distracts. Render all rows uniformly dimmed. aria-selected still marks the
logical active tab for assistive tech; only the visual emphasis is removed.
Dragging across the seam between two vertically stacked docked panels [A above B]
flickered: A's content bottom band drew the hint line at A.bottom, B's grip bar
drew it at B.top (7px apart, across the divider), and the divider gap itself hit
no target -> a NONE frame. Three slivers that all mean 'insert between A and B',
read as a jumpy/blinking target.

Hint-only fix (drop OUTCOMES unchanged): snap both bands' line to the divider
gap CENTER when the split lands on a seam shared with an adjacent stacked sibling
(dockedSeamSibling: same edge, horizontally overlapping, gap <= divider+slack),
and map the divider gap itself to that same seam split so there's no dead frame.
Single-panel / region-edge splits (no sibling) keep the panel-boundary line +
on-screen clamp. Added hitTest seam tests (bands + divider + 1px sweep: one
stable line, no NONE; single-leaf still anchors to its own edge).

Verified live: seam sweep went from 403->NONE->410 to a stable 406, 0 dead
frames; drop still inserts between A and B.
The main panel's header toggled minimize on a no-motion tap, but ordinary panel
grip bars deliberately did not -- minimize was only on the small +/- button -- an
inconsistency between the main panel and every other panel. Make a motionless tap
ANYWHERE on the grip handle toggle minimize/expand too, matching the header. The
+/- button stays as an explicit, redundant cue.

Safe because armPress already splits click-vs-drag at a 3px motion threshold: a
real drag (move the panel) exceeds it and never fires the tap toggle. Verified
live: tap grip -> minimize, tap strip -> expand, drag grip -> moves (no
accidental minimize). Updated the e2e (test_handle_tap_does_not_minimize ->
test_handle_tap_toggles_minimize) to assert the new behavior.
brentyi added 26 commits June 26, 2026 07:50
Cleanup pass over the recent dock minimize work (no behavior change):

- applyLeafPreview reads collapse from the layout model (nodeAllMinimized)
  instead of a per-frame DOM querySelector for data-dock-collapsed -- the
  model is the source of truth, and it drops a DOM read from the drag hot path.
- hitTest 3z: the insertTab result+hint is built once (insertResult) and
  shared by the docked and floating arms instead of duplicated, with the same
  precedence preserved.
- FloatingWindowView: use windowAllMinimized() instead of inlining the
  every-group-collapsed check; dropped a stale collapsedByParent comment.
- layoutOps: extract columnLeafGroups() -- the "a column split's direct leaf
  children are one stack" notion now lives in one place, used by both
  stackGroupIdsOf and normalizeStackCollapse (was duplicated with leaf casts).

Skipped (noted): the per-render stackGroupIdsOf walk in TabGroupFrame (dock
trees are tiny and renders are memoized -- a memoization layer would add more
complexity than it saves); the toggleAll/toggle two-line duplication across
FloatingWindowView/SplitView (idiomatic, different surrounding context); a
broader "stack concept" unification (acceptable as-is per review).
A panel in a 2+ minimized stack no longer shows its own + cap -- minimize/
expand is owned by the parent stack handle, so each cell's cap is now a plain
drag-grip pill (no +), matching an expanded stacked panel's grip bar. A LONE
minimized cell keeps its + expand button. The stacked cell's cap is drag-only
(no click-to-toggle), consistent with the expanded stacked grip; the spine
rows still expand-to-tab and the parent + still expands all.

VerticalMinimizedCell gains an `inStack` prop (passed from VerticalMinimizedColumn
via leaves.length>1 and from FloatingWindowView via `multi`).
- A DOCKED titleNode header (the main panel's "Connected" connection-status
  bar) now gets a thin 1px top rule, so it reads as separated from the region's
  top edge. Scoped to docked only (a floating window has its own chrome above);
  same gray as the existing bottom headerRule.
- The minimized stacked cell's grip pill is smaller and more subtle (width
  0.9em, opacity 0.35) -- the cell is just a label; the parent handle is the
  primary drag/expand affordance.
The main panel's "Connected" header top rule now appears only when docked AND
stacked (below another docked panel) -- not when docked alone (nothing above
it) or floating. The color now matches the bottom headerRule exactly (gray-3
light / dark-4 dark, via a new headerRuleTop class) instead of the slightly
darker default-border.
The main panel's "Connected" header top rule now appears whenever the panel
is in a 2+ stack -- docked OR floating -- not only when docked. More visually
consistent (a floating stacked header also sits below another panel).
A fixed-height floating stack with cells of flexShrink:1/minHeight:0 let an
over-short window shrink a cell below its own header -- clipping it and
overlapping the next cell (the "Connected" header rendered half-hidden behind
the tab strip below it). Floor each non-collapsed cell at MIN_STACK_CELL_PX so
it always shows its header, and make the stack scroll (overflowY:auto) when the
cells' floored heights exceed the pinned window height.
startColumnDrag measured the rendered column wrapper for the float width, but a
MINIMIZED column renders as a ~36px strip -- so undocking a minimized stack
produced a strip-narrow window that stayed tiny even after expanding. Mirror
the single-group tear-out fix: when the column is minimized, float at the
region's preserved expanded regionWidth instead. treeFindNode is exported (and
now null-tolerant) for the lookup.
The "a minimized docked item floats at its region's preserved expanded width,
not its strip-narrow measured width" rule lived inline in both startGroupDrag
and startColumnDrag. Extract dockedFloatWidth(edge, collapsed, measuredWidth)
so both share it and a future float path can't reintroduce the strip-width bug.
No behavior change.
…ent)

A floating window's resize ceiling was its CONTENT height, so an auto-height
window (e.g. an undocked stack) couldn't be grown -- dragging the bottom grip
down snapped it SMALLER. Now the ceiling is the container edge only; a window
(single panel OR stack) can be dragged taller than its content, with the extra
space empty for a lone panel and shared by weight in a stack (matching a docked
panel filling its region). One uniform rule -- no single-vs-stack special case.

Reverting to auto-height is now an explicit magnetic detent at the content
height: dragging the edge within a snap band sticks it exactly at content (the
sole "revert to auto" position), and a 2px primary-colored bottom-edge bar
highlights while snapped so the behavior is discoverable. Releasing in the
detent reverts to auto; any other height pins (taller = empty/weight space,
shorter = the body scrolls).
Both the heightFrom magnetization and snappedToContent guarded "content height
is within [minHeight, maxHeight]" separately (and snappedToContent's bound was
only correct because h was pre-clamped). Extract a single contentReachable flag
they share -- clearer, and snappedToContent is now correct for any input.
No behavior change.
measureContentHeight summed each scroll viewport's scrollHeight, but when the
window is TALLER than its content the viewport stretches and scrollHeight
collapses to clientHeight (== the window height) -- so "content height" was
reported as the CURRENT height, and the revert-to-auto detent snapped
everywhere while shrinking from a too-tall window. Measure the
.mantine-ScrollArea-content wrapper's offsetHeight instead, which keeps the true
content height regardless of the window height.
Lower the three (separate) min-height floors so panels can be resized tighter:
- MIN_WINDOW_HEIGHT_PX 100 -> 50 (floating window scroll floor)
- MIN_CELL_HEIGHT_PX   80 -> 50 (docked column cell)
- MIN_STACK_CELL_PX    60 -> 50 (floating stack cell)
Kept as separate constants (distinct surfaces, independently tunable). Updated
the Python mirror in dock_helpers.py and the cappedWindowHeight unit tests to
reference MIN_WINDOW_HEIGHT_PX instead of hardcoding 100.
When a docked panel/column minimizes (or expands), the region width now eases
between its full width and the ~36px strip width (and the canvas inset it drives
eases with it), instead of snapping. The strip glides in/out.

Mechanism: applyOp pulses a short-lived `animatingMinimize` flag ONLY when an op
flips a group's collapsed state (detected via a collapsed-set signature); any
other op cancels the pulse. The region/canvas boxes carry the width transition
only while that flag is on AND no resize is active, so:
- minimize/expand animates,
- interactive resize (column divider, region divider) stays 1:1 -- RegionResizer
  now sets the shared `resizing` flag so a resize right after a minimize cancels
  the leftover ease.
During the ease the canvas ResizeObserver follows a beat behind (the accepted
minimize-only canvas lag; interactive resize remains instant via syncCanvasSize).

Tests: e2e suites that minimized-then-measured-width now wait out the ~200ms
ease before measuring (the assertions -- strip width, 1:1 resize tracking,
reserved-divider resize -- are unchanged and still hold once settled).
A single minimized docked panel is a short content-tall strip, so the large
empty region area below it had NO drop target -- you couldn't dock another panel
beside it except over the ~120px strip. The region's full-height left/right
"dock beside" band is normally suppressed for a single-leaf region (its
per-panel split is identical), but that split is only strip-tall when minimized.

Fix: keep the region's full-height side band for a MINIMIZED single-leaf region,
EXCEPT directly over the strip cell (which owns its tab-insert/merge/stack
zones). In the empty area the whole column width docks a sibling (effSideBand =
full width there, so there's no dead center stripe). Over the strip rows, the
cell zones still win.

Tests: hitTest cases for the full-height beside-zone below the strip and for the
cell zones still winning over the rows.
This reverts commit 204b0757fedc68bba28601bb9500a9bdda255b50.
The prior fix only made the canvas-facing (left) side of a single minimized
strip a full-height "dock beside" zone; the outer (right) edge still gave the
cell's strip-tall split, so a partial-height hint appeared there. Make it
symmetric: over a minimized single-leaf strip, BOTH side bands span full height
(effSideBand applied to the left AND right checks), and the band is no longer
fully suppressed over the strip -- only its MIDDLE third falls through to the
cell's tab-insert/merge zones, while the outer/inner thirds dock a sibling
column full-height. The empty area below still docks across the whole width.

Tests: the collapsed-strip vertical-zone tests now use realistic minimized
geometry (strip flush at the screen edge, reserved width == strip width) and
probe the strip's middle third for the cell zones.
Each panel placement command (dock_*/float/set_width/set_height/minimize) now
bumps a per-panel counter (GuiApi._layout_update_count_from_uuid) stamped onto
the 4 GuiSetPanel* messages via the single _queue_placement chokepoint. gui.reset
bumps + stamps the control panel's counter on its reset messages so a deliberate
reset still beats a client's last-applied count.

This lets the client (next commit) ignore replayed/late placement for a panel
the user has rearranged -- so a reconnect or program re-run doesn't clobber the
user's layout -- while the server can still re-assert by calling a placement
method again (which increments the counter). Client field is unused for now.
The dock booted empty and re-applied the server's replayed placement on every
(re)connect, clobbering any layout the user had dragged/docked/minimized in the
browser. Now the client gates re-application per panel:

- panelPlacement entries carry the server's per-panel counter (threaded through
  the 4 GuiSetPanel* handlers).
- A new panelLayoutTracking store ({appliedCounter, userTouched} keyed by a
  STABLE key) deliberately SURVIVES resetGui. The stable key is derived from a
  panel's tab labels + creation order (panel uuids are random per run), so a
  panel re-binds to its remembered layout across reconnect.
- The placement effect applies server placement only when the counter exceeds
  the last applied OR the user hasn't moved the panel (and an untouched panel
  adopts the counter even if lower -- a re-run resets the server's counter).
- DockManager gained onCommit(prev, next, programmatic); ControlPanelDockSurface
  diffs per-group placement signatures on USER (non-programmatic) commits and
  marks the affected panels user-touched. api.apply runs under a programmatic
  flag so server-driven applies don't self-mark.

The tracking is in-memory: it survives a websocket reconnect (covered by
test_panel_layout_persistence.py) but not a full page reload, by design. The
server can still re-assert a moved panel by calling a placement method again
(counter increments past the last applied).
Both predated this branch's UI changes and failed against the built client:

- test_undock_minimized_panel_keeps_width dragged the minimized strip to a point
  that landed ON the control panel (top-right), merging them into a multi-group
  stack instead of tearing out a solo float. Drop in the lower-left canvas,
  clear of the control panel. The assertion (undocked+expanded width survives)
  is unchanged.

- test_unminimize_after_sibling_resize_keeps_panel_onscreen minimized ONE panel
  of a docked column via a per-panel button. Under stack-uniform-collapse a 2+
  docked stack collapses/expands as a whole via the column handle's
  minimize-all, with no per-panel minimize. Rewritten to resize the divider
  (px-rescaling the weights) then minimize+expand the whole column, asserting
  all three panels return on-screen with real height -- same regression intent
  (restore-weight rescale) under the supported gesture.
The counter/dirty-bit makes a user-moved panel sticky -- the server can't
reposition it without a counter bump -- so a user who rearranges (or fat-fingers)
a panel had no way back to the server's layout short of a reload. Add a reset:

- guiActions.resetPanelLayout clears panelLayoutTracking and bumps a new
  layoutResetNonce; the placement effects watch the nonce, clear their per-panel
  applied-key, and re-apply each panel's server placement (the cleared tracking
  lets the gate re-seed every panel).
- DevSettingsPanel shows a "Reset Panel Layout" button, enabled only when some
  panel is user-touched (layout actually changed), disabled otherwise.

Covered by test_panel_layout_persistence.py: button disabled -> drag a panel ->
enabled -> click -> panel returns to server placement, button disabled again.
Cleanup from the simplify pass (no behavior change to the feature):

- Extract buildPaneToStableKey (one O(n log n) bucket-by-label pass) as the
  single source of the stable-key rule; useStableKey and handleCommit both call
  it instead of each re-deriving it (the inline copy was O(n^2)). Removes stray
  NUL-separator join duplication too.
- groupPlacementSignatures reuses ops.collectLeaves instead of a hand-rolled
  tree walk.
- Collapse the four setPanel* store actions onto one mergePlacement helper.
- handleCommit early-outs when no group signature changed (a region-width-only
  commit), skipping the pane->key map build entirely.

Also fixes the dirty-bit's programmatic flagging (found in review):
- Registry reconciliation (panel removed server-side) now commits via
  runProgrammatic, so a removal that shifts siblings' placement signatures can't
  spuriously mark surviving panels user-touched.
- Region-width resize commits via runProgrammatic too -- region width isn't a
  per-panel placement the dirty bit tracks, and these fire per drag frame, so
  this also stops running the commit handler ~60x/s for an ignored change.
Code review found the layout-tracking store (keyed by stable key = tab labels +
order) was never pruned, despite its docstring claiming so. Consequence: removing
a user-touched panel and adding a new one with the same labels made the new panel
inherit the removed one's {userTouched, appliedCounter}. Since a fresh panel's
server counter starts low, the gate (userTouched && counter <= appliedCounter)
then SILENTLY suppressed the new panel's server placement -- it floated at default
instead of docking where the server put it.

Fix: a pruneLayoutTracking(activeKeys) action, called from ControlPanelDockSurface
whenever the panel set changes, drops entries whose stable key is no longer
present. Covered by a new e2e (remove a touched panel, re-add same-label panel ->
it docks per the server, doesn't inherit stale state).

Also tidy the duplicated doc comment over buildPaneToStableKey.
Tapping the "Connected" main-panel header collapsed the WHOLE stack when it was
stacked (uniform collapse), which is unintuitive. Now the header's click-to-
minimize is wired only when the panel is lone (floating or docked by itself); in
a 2+ stack the header is drag-only (relocate), and minimize is via the parent
stack handle. Matches the per-panel grip's existing lone-only minimize.
Minimize/expand now eases for docked panels too, not just floating:

- SplitView: the per-cell flex-grow/flex-basis transition now also runs for a
  column collapsing within a ROW (a minimized column among expanded ones), not
  only the vertical column-stack case -- so a multi-column region's minimize
  eases the cell to a strip while its expanded siblings grow smoothly, with NO
  wobble (the cell, not the container, drives it). Still suppressed during a
  divider drag.
- DockManager: a lone docked region has a single leaf (no per-cell flex to
  animate), so ease the region width + canvas inset directly -- but ONLY on a
  minimize/expand. An `animatingMinimize` pulse (set when a commit flips some
  group's collapsed set) gates the width transition, so drag/dock/tear-out/
  resize stay instant. RegionResizer marks `resizing` so a resize right after a
  minimize still tracks the cursor 1:1.

This is the clean version of the earlier reverted attempt: animating the cell
(not just the container) is what removes the sibling wobble that forced the
revert.

Tests: dock e2e suites that minimize-then-measure width now wait out the ~200ms
ease before asserting settled geometry.
Cleanup from a review pass over the minimize-animation code:

- Replace the collapsed-set signature compare with collapseFlipped(prev, next),
  which only fires when a group present in BOTH layouts changes its collapsed
  flag. Removing a collapsed group (panel removed) no longer looks like a
  minimize, so it doesn't spuriously ease the region width.
- Cancel an in-flight minimize pulse when a NON-flip commit lands inside its
  window, so a drag/dock/tear-out right after a minimize doesn't inherit the
  width ease (the gate was purely time-based before).
- Share one DOCK_ANIM_MS (in types.ts) across DockManager (region width),
  SplitView (per-cell flex), and TabGroupFrame (<Collapse>), so the "these must
  match" invariant is structural rather than three independent 200s.
- Honor prefersReducedMotion() on the region-width and per-cell flex transitions,
  matching every other dock animation.

Behavior verified unchanged: lone region eases, multi-column eases per-cell with
no sibling wobble, drag/dock stay instant, and removing a collapsed group no
longer animates.
From a deeper review pass over the layout-persistence feature:

- Reconnect prune race (high): resetGui empties state.panels before the server
  replays panel creations, which fired the prune effect against an empty set and
  wiped EVERY standalone panel's {userTouched, appliedCounter} tracking -- the
  exact state meant to survive a reconnect. The placement effect then re-applied
  server placement and clobbered the user's arrangement (timing-dependent; it
  often raced favorably, but was fragile). Fix: skip pruning while panels is
  empty -- an empty set means "not loaded yet / mid-reset", never "all removed".

- Floating-stack sibling over-flag (medium): a floating group's placement
  signature included its stack INDEX, so tearing one pane out of a stack shifted
  the others' indices and falsely marked them user-touched (suppressing future
  server re-placement at the same counter). Fix: key the floating signature on
  the window id + geometry + collapsed, not the index -- staying in the same
  window isn't a user relocation.

Adds a multi-panel reconnect e2e (touched panel stays moved, untouched re-docks)
that exercises the prune path against a real panel set. Also names the
pulse-timer's +40ms slack.
brentyi added 3 commits June 29, 2026 09:26
CI runs `ruff format --check`; the counter-stamping edits left a few lines
ruff wanted reflowed (single-line raises, quote style). Formatting only, no
behavior change.
- test_disconnect_freezes_gui_instead_of_wiping read the dim opacity once,
  immediately after the socket dropped, so under `-n auto` it could race the
  client's disconnect handling and see opacity 1 (the flake seen in a full CI-
  style run). Poll for the dim (up to ~5s) instead; the assertion is unchanged.
- Apply ruff format to the tests/ files touched this session (pure Python
  reflow; the formatting CI job only checks docs/src/examples, but keep the tree
  tidy).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants